04 - 多租户与配额:两条完全不同的路
模型账单只有一张,但用它的有十个团队。"这次请求算谁头上、还剩多少额度、超了怎么办" —— 这是网关从"路由器"变成"平台"的那一步。
LiteLLM 和 Envoy AI Gateway 在这件事上给出了两个完全不同的答案,而且分歧的根源不是偏好,是形态(见 02 - 四种形态)。
前置:01 - 网关是什么 里的虚拟密钥概念。
本篇回答:十个团队共用一张模型账单时,"还剩多少额度、这次算谁头上"是怎么被算准的。
会用到的词:
- TOCTOU(Time-Of-Check to Time-Of-Use):先检查再使用,中间被别人插了一脚 —— 限流被击穿的经典原因
- Redis Lua 脚本:Redis 单线程执行 Lua,所以一段脚本里的多个操作天然原子,是实现分布式限流最常见的手段
- descriptor(描述符):限流的对象标识,比如"研究团队这把密钥"或"gpt-4o 这个模型",一次请求可以同时携带多个
- Redis Cluster / hash tag / CROSSSLOT:Redis 集群把 key 分散到不同分片,一次操作碰不到跨分片的多个 key(报 CROSSSLOT),用花括号
{}标记的部分相同则保证落在同一分片
一、LiteLLM:用 Redis Lua 脚本实现限流
不用原子操作时,并发请求会击穿限额:
litellm/proxy/hooks/parallel_request_limiter_v3.py 有 4,789 行,是整个 proxy 里最硬核的一个文件。它的核心不是 Python,是嵌在里面的两段 Redis Lua 脚本。
1.1 为什么必须用 Lua
限流的经典问题是 TOCTOU(check-then-act):读计数 → 判断 → 写计数,这三步之间别的副本插进来了,限额就被击穿。多副本部署下这不是理论问题,是必然发生的。
LiteLLM 的解法是把整个"检查并递增"塞进一个 Lua 脚本,靠 Redis 单线程执行保证原子性。脚本头部的注释把契约写得非常完整:
-- Atomic check-and-increment-by-N across one or more descriptors.
-- All-or-nothing: if any descriptor would exceed its limit, no counter is
-- modified.
--
-- Uses Redis server time (`redis.call('TIME')`) instead of a client-supplied
-- timestamp so that window resets are deterministic across replicas with
-- skewed wall-clocks. This prevents a clock-skew-induced reopening of the
-- TOCTOU window across multi-replica deployments.
--
-- KEYS layout: pairs of (window_key, counter_key), one pair per descriptor.
-- ARGV layout: per-descriptor 4-tuple, starting at ARGV[1]:
-- ARGV[(i-1)*4 + 1] = limit
-- ARGV[(i-1)*4 + 2] = increment
-- ARGV[(i-1)*4 + 3] = ttl_seconds (counter TTL when window resets)
-- ARGV[(i-1)*4 + 4] = window_size_seconds (sliding-window length)
--
-- Return on success:
-- { 0, new_counter_1, window_start_1, new_counter_2, window_start_2, ... }
-- Return on over-limit: { 1, descriptor_index, current_counter, limit }
三个设计点值得单独拎出来:
1. 用 redis.call('TIME') 而不是客户端时间戳。
local time_reply = redis.call('TIME')
local now = tonumber(time_reply[1])
如果时间由 Python 端传进来,多个副本的机器时钟哪怕只差几百毫秒,窗口重置就会在不同副本上发生在不同时刻 —— 等于重新打开了刚被 Lua 关上的 TOCTOU 窗口。注释里明确说了这是在防 "clock-skew-induced reopening"。这是那种只有真被线上打穿过才写得出来的注释。
2. 两趟扫描,全有或全无。
-- Pass 1: read state, validate. Abort without writing if any over limit.
local descriptor_state = {}
for i = 1, descriptor_count do
...
end
第一趟只读不写,任何一个描述符超限就整体中止;第二趟才真正递增。这样"key 的额度够但 team 的额度不够"时,不会出现 key 的计数被扣了而请求被拒的错账。
3. Redis Cluster 的 hash tag。
window_key = f"{{{descriptor_key}:{descriptor_value}}}:window"
那三层大括号在 Python f-string 里最终渲染成 {descriptor_key:descriptor_value}:window —— 花括号是 Redis Cluster 的 hash tag 语法,保证同一个描述符的 window 和 counter 落在同一个 slot 上。源码注释解释了后果:
Cluster-safety: each descriptor's keys all share a `{key:value}` hash
tag, so the Redis Lua path issues one Lua call per descriptor — every
... CROSSSLOT errors. Cross-descriptor atomicity is preserved via
refund-on-rollback: if descriptor i is OVER_LIMIT, descriptors 0..i-1
这里有个诚实的妥协:在 Redis Cluster 下,跨描述符的原子性做不到了(不同描述符在不同 slot,一次 Lua 调用碰不到),于是降级成"退款回滚"——先扣,发现后面的超限了再把前面扣的还回去。单实例 Redis 下是真原子,Cluster 下是补偿事务。这个区别在文档里不会写,只在源码注释里。